gguf: release the weight mmap and the split loader's overflow chunks - #347
Merged
CrispStrobe merged 4 commits intoAug 13, 2026
Merged
Conversation
core_gguf::load_weights maps the whole GGUF and hands the region to the
device on the zero-copy path. `buffer_from_host_ptr` has no deallocator
parameter, and Metal passes `deallocator:nil`, so freeing the backend
buffer released the device-side view and left the host mapping in place.
The loader dropped its only handle to a region it still owned, and the
side map that recorded it had no caller on any release path.
The comment being deleted argued this was affordable because the kernel
can still evict file-backed pages under pressure. That is false for this
mapping. It is created MAP_PRIVATE with PROT_READ|PROT_WRITE — MappedFile's
`writable` parameter, which exists for backends that fold weights in place
after load — so macOS resolves the copy as each page faults in and merely
*reading* the weights privatizes them. A standalone probe that reads one
byte per page and writes nothing takes a 658 MB GGUF to 658.7M resident /
658.7M dirty, with Metal not involved; the control, the same file mapped
MAP_SHARED|PROT_READ and handed to ggml_backend_dev_buffer_from_host_ptr,
stays at zero dirty pages through the same handoff and after the backend
buffer is freed. Dirty private pages can be compressed or swapped, never
dropped, so the cost is real memory for the life of the process.
Measured on an M4 Max, one process, tests run serially: two Omni CTC
sessions (3B q8 then 7B q8) peaked at 11.25 GB, the sum of both models,
against 7.73 GB with the mmap loader disabled. Accumulation is per *load*,
not per file — twenty load/free cycles of one GGUF left twenty mappings —
so repeated loads of a single weight accumulate exactly like distinct ones.
A downstream suite's four forced-aligner sessions cost 2.90 GB against
1.37 GB. Both figures now match the mmap-disabled run to within 0.07%.
Add core_gguf::release_weight_buffer(): take-and-erase the side-map entry
in one critical section, free the backend buffer, then unmap. Taking the
entry before the free is what makes a double release safe and stops a
concurrent load whose fresh buffer lands on the same address from having
its record erased. Unmapping after the free keeps a device-side view from
outliving its pages, which is the precondition ggml already imposes —
ggml_metal_buffer_free vm_deallocates the host pages of a buffer it owns.
No vtable entry is touched: ggml_backend_buffer_is_metal classifies a
buffer by comparing iface.free_buffer against Metal's own callbacks, so
substituting that pointer would have unclassified every weight buffer.
free_weights and all six of the loader's failure paths route through the
new entry point, including the GPU bounds-check rejection, which abandoned
a registered buffer entirely — neither freed nor unmapped nor erased. That
branch is reached by a truncated or crafted GGUF, so it was a leak an
untrusted input could trigger on demand. gguf_loader.cpp now contains one
ggml_backend_buffer_free call, inside release_weight_buffer.
Converted here: omniasr and the wav2vec2/MMS aligner, the two backends
this was measured against. The fork's other GGUF consumers still free
their weight buffers directly and keep the leak until they are converted.
Tests, each failing before this change and passing after:
test-gguf-release the entry point's contract on paths needing
no GPU — the CPU mmap path, the legacy
alloc+copy path where no entry was ever
registered, repeat calls, the null handle.
test-gguf-mapping-released after free, no region of the process may name
the weight file. Exact rather than a footprint
threshold: nothing to settle, nothing to poll.
Pre-fix the GPU case leaves 1 region and the
twenty-cycle case leaves 20.
test-gguf-bounds extended for the abandoned-buffer path above.
The region probe reads PROC_PIDREGIONPATHINFO so the region and its path
come from one record; proc_regionfilename alone answers with the file of
the next region at or above the address, which counts an unrelated
anonymous neighbour as a mapping of the weight file.
Note for the CrispEmbed copy: core/gguf_loader.{h,cpp} exists in both
repos and tests/test-copies-in-sync.cpp compares paths within one checkout,
so it cannot see this pair. This adds a public core_gguf function and needs
the matching patch there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The release entry point only helps callers that use it. The previous
commit converted omniasr and the wav2vec2 aligner; this converts the
rest, so a fork-wide load no longer leaves the weight file mapped.
135 call sites across 89 files. The set was derived by provenance, not by
name: a free is converted only where its handle was assigned from a
WeightLoad field, and never where the same expression is also assigned
from ggml_backend_alloc_ctx_tensors / alloc_buffer / buft_alloc_buffer.
That second rule is what keeps compute and KV-cache buffers out — the
converted names are buf, buf_w, buf_cpu, buf_w_cpu, w_buf and friends,
while kv_buf, buf_perm, cross_kv_buf, fused_buf, bake_buf and buf_f32 are
left alone. Every case where the two rules disagreed was checked by hand:
titanet's g.buf, moss_tts_codec's and moss_tts_local_codec's w_buf and
indextts's beam-pool buf are all separately allocated and stay as they
were.
Four cases per-file provenance cannot see, handled directly:
moonshine-impl.h frees buf_w / buf_w_cpu for a load that happens in
moonshine.cpp, so the header had no load_weights
call to trace. Now includes core/gguf_loader.h.
voxcpm2_tts.cpp calls load_weights_filtered through a
`using namespace core_gguf`, so the qualified-call
scan did not match it.
crisp_punc/src/{fireredpunc,pcs}.cpp and crisp_lid/src/lid_cld3.cpp
are second copies of files under src/, kept in sync
by tests/test-copies-in-sync.cpp. A src-only sweep
left them behind and that test caught it.
Backends left unconverted, verified rather than assumed: the seven that
already free through core_gguf::free_weights, which the previous commit
routed through the release path; crispasr.cpp, whose Whisper loader
allocates a backend-owned buffer with
ggml_backend_alloc_ctx_tensors_from_buft and never maps; and
crispasr_vad_encdec.cpp, marblenet_vad.cpp, miotts.cpp, omnivoice.cpp and
core/{attention,dac_decoder,fastconformer}.h, whose buffers come from
their own allocations rather than from this loader.
ctest -L unit: 1602 passed. A downstream suite that exercises Omni CTC,
Whisper, the forced aligner, diarization and enhancement passes with
identical results and identical goldens before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
load_weights_split() allocates a partition larger than 1.5 GiB as several backend buffers. The first went into WeightLoad::buf / ::buf_cpu and the rest into ::split_bufs, documented as the caller's to free. No caller ever did. All eighteen backends that call load_weights_split move buf and buf_cpu into their own model struct and let the local WeightLoad die with the vector still in it, so every overflow chunk was leaked for the life of the process — up to 1.5 GiB each, on any backend, not only the AMD Vulkan case the chunking was written for. free_weights() freed them correctly and is reached by none of those eighteen. An obligation that no caller has honoured is in the wrong place, so the overflow chunks are now owned by the loader, keyed to the first buffer of their own partition and released with it by release_weight_buffer(). That fixes all eighteen without touching any of them. split_bufs still lists the chunks so a caller can see how a load was partitioned, and is now documented read-only; free_weights() clears it instead of freeing it, since freeing there would be a double free. Reaching the chunked branch used to need a multi-gigabyte allocation, which is why it had no test and why this went unnoticed. CRISPASR_GGUF_MAX_ALLOC_CHUNK lowers the limit, so a synthetic eight-tensor model chunks at 1 KiB and the path runs in the unit tier. The new case asserts the overflow really happened before asserting anything about the release — without that control it would pass having never chunked — and that a second release is a no-op rather than a double free. What it does not prove is that the memory came back: ggml exposes no per-backend allocation counter to assert against, and a footprint threshold would be the flaky case in this suite. The hand-populated "free_weights clears split_bufs" case is replaced by that end-to-end one. It pushed buffers the loader had never issued into split_bufs, which is exactly the use the field no longer supports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fork-wide conversion missed one consumer. crisp_audio's load_model()
moves core_gguf::load_weights' buffer into crisp_audio_context::model_buf,
and crisp_audio_free() destroyed it with ggml_backend_buffer_free() — so on
a device advertising buffer_from_host_ptr the device view went away and the
whole-file mapping stayed resident and dirty for the process's life. Same
leak the previous two commits fixed everywhere else.
crisp_audio/ escaped the earlier sweep because it is neither under src/ nor
a duplicate of anything under src/: the qualified-call scan looked in src/,
and tests/test-copies-in-sync.cpp — which caught the crisp_punc/ and
crisp_lid/ copies — enumerates {crisp_punc, crisp_lid, crisp_truecase} and
compares against a src/ twin that crisp_audio has none of. So it is a fifth
class of escape alongside the four the last commit listed, not an instance
of any of them.
Re-ran the provenance rule over all 1201 tracked C/C++ files outside ggml/,
third_party/ and the untracked bindings/ruby/ext/sources/ copy, intersecting
"assigned from a WeightLoad field" with "passed to ggml_backend_buffer_free":
this site and one trailing-name collision in titanet (ctx->g.buf freed,
ctx->buf loaded; g.buf is alloc_ctx_tensors and was already checked by hand).
Nothing else. The three headers cleared by assertion last time —
core/{attention,dac_decoder,fastconformer}.h — allocate locally via
buft_alloc_buffer / alloc_ctx_tensors, so no cross-file escape hides there.
Two shipped consumers reach the fixed path in-tree: qwen3_asr and higgs_stt
both link crisp_audio (src/CMakeLists.txt:1171, :1337). CrispEmbed's
BidirLM-Omni audio path links the same library and needs the matching
core/gguf_loader.{h,cpp} sync.
The failure path shares the fix: crisp_audio_init_from_file calls
crisp_audio_free when load_model returns false, so a GGUF that maps and is
then rejected for missing tower tensors leaked the same way.
tests/test-crisp-audio-mapping-released.cpp drives the public C API on a
synthetic one-layer tower — no model file, no download — and asks the kernel
which regions still name the GGUF. Pre-fix: 1 leaked region on a single
init/free, 5 across five cycles (per-load accumulation, reproduced one level
up from the loader), 1 on the rejected load. Post-fix: 0 on all three. The
CPU leg passes either way, as the file states, and is there so the case is
not a pure skip on hosts with no host-pointer GPU.
ctest -L unit -E live: 1606 passed, 0 failed. clang-format 18 clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Owner
|
Reviewed and validated locally on Apple Silicon against the current claimed main. Validation:
No regression or additional fix was found. The companion CrispEmbed branch |
Owner
|
Companion landed in CrispEmbed via #47, merge commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
core_gguf::load_weightsmaps the whole GGUF and hands the region to the device on the zero-copypath.
buffer_from_host_ptrhas no deallocator parameter, and Metal passesdeallocator:nil, sofreeing the backend buffer released the device-side view and left the host mapping in place. The
loader dropped its only handle to a region it still owned —
lookup_gpu_mmap, the function thatwould find it again, had no caller on any release path.
The result on Apple silicon is that a process holds every weight file it has ever loaded. Two Omni
CTC sessions in one process (3B q8 then 7B q8, serial) peaked at 11.25 GB, the sum of both
models, against 7.73 GB with
CRISPASR_GGUF_MMAP=0. The accumulation is per load, not perfile: twenty load/free cycles of a single GGUF left twenty live mappings.
The comment being deleted is wrong, and here is the measurement
gguf_loader.cppargued the leak was affordable because "on macOS the kernel can still evictfile-backed pages under pressure (they're not anonymous)". That is false for this mapping.
MappedFile(path, /*writable=*/true)mapsMAP_PRIVATEwithPROT_READ|PROT_WRITE— thewritableflag exists for backends that fold weights in place after load, e.g. parakeet'sbatch-norm-into-conv — and macOS resolves the private copy as each page faults in. So merely
reading the weights privatizes them.
A standalone probe that reads one byte per page and writes nothing anywhere in the program takes a
658 MB GGUF to:
The control is the same file mapped
MAP_SHARED | PROT_READand handed toggml_backend_dev_buffer_from_host_ptr:— fully resident, zero dirty, and still zero after the backend buffer is freed. Dirty private
pages can be compressed or swapped, never dropped, so the cost is real memory for the life of the
process. The mapping mode is the whole cause. A read-only consumer that never folds weights could
take the
MAP_SHARED|PROT_READpath instead and would not need any of this; that option is worthknowing about but it is not what the loader does today.
The CPU mmap path was never affected — its
mmap_buffer_freecallsmunmap. This isApple-silicon-specific, or more precisely specific to any device advertising
buffer_from_host_ptr.The fix
core_gguf::release_weight_buffer(ggml_backend_buffer_t&): take-and-erase the side-map entry in onecritical section, free the backend buffer, then unmap, then null the caller's handle.
a new buffer at the same address and register it, and the erase would drop a live mapping's record.
existing caller contract rather than adding one —
ggml_metal_buffer_freealreadyvm_deallocatesthe host pages of a buffer it owns, so "no work referencing this buffer may still be in flight" is
a precondition every caller already meets.
path register nothing and reach the same call.
No vtable entry is touched. An earlier attempt replaced
iface.free_bufferon the inner buffer,reasoning that
contextand every other method stay Metal's own. That is wrong: that pointer isMetal's type tag.
ggml_backend_buffer_is_metalclassifies a buffer by comparingiface.free_bufferagainst the shared and private Metal free callbacks (
ggml-metal.cpp:176-179), and these buffers arebuilt with
ggml_backend_metal_buffer_shared_i, so a substitution would unclassify every weightbuffer. This fork uses the same pointer as a type tag itself, in
mmap_advise_random.If you would prefer a universal hook, the way to make one safe is to classify a Metal buffer by
its
buft— the idiomggml_backend_metal_device_supports_buftalready uses — after which a vtableshim releases the mapping for every model at once and needs no per-backend change. That is a
ggml-metalchange rather than a CrispASR one, so it is offered here rather than included.A second leak on the same page
load_weights_splitallocates a partition above the 1.5 GiB chunk limit as several backend buffers,putting the first in
WeightLoad::buf/::buf_cpuand the rest in::split_bufs, documented as thecaller's to free. All eighteen backends that call it drop the vector — each moves
bufandbuf_cpuinto its model struct and lets the localWeightLoaddie.free_weights()frees themcorrectly and is reached by none of those eighteen. The chunk limit is unconditional, so this is not
only the AMD Vulkan case it was written for.
Ownership moved into the loader: the overflow chunks are keyed to the first buffer of their own
partition and released with it, which fixes all eighteen without touching any of them.
split_bufsremains as a read-only listing of how a load was partitioned. Happy to invert this ifyou would rather keep caller ownership and patch the eighteen call sites instead — but a contract
that every caller has ignored seemed like the wrong place for the obligation.
Third leak: a rejected GGUF
The GPU bounds-check rejection set
out.buf = nullptrand fell through to the legacy loader,abandoning the backend buffer and the whole-file mapping registered against it — neither freed nor
unmapped nor erased. That branch is reached by a truncated or crafted GGUF, so it was reachable on
demand by untrusted input. It now releases.
gguf_loader.cppcontains exactly oneggml_backend_buffer_freecall, insiderelease_weight_buffer.Call-site conversion
136 sites across 90 files. The set was derived by provenance, not by name: a free is converted only
where its handle was assigned from a
WeightLoadfield, and never where the same expression is alsoassigned from
ggml_backend_alloc_ctx_tensors/alloc_buffer/buft_alloc_buffer. That secondrule keeps
kv_buf,buf_perm,cross_kv_buf,fused_buf,bake_bufandbuf_f32out. Everycase where the two rules disagreed was checked by hand —
titanet'sg.buf,moss_tts_codec's andmoss_tts_local_codec'sw_buf, andindextts's beam-poolbufare separately allocated and stayas they were.
Five classes escape per-file analysis and were handled directly:
moonshine-impl.husing namespace core_ggufvoxcpm2_tts.cppsrc/filecrisp_punc/{fireredpunc,pcs}.cpptests/test-copies-in-sync.cppsrc/filecrisp_lid/lid_cld3.cpptests/test-copies-in-sync.cppsrc/nor a duplicate of anything therecrisp_audio/src/audio_tower.cppcrisp_audiois the one that got away, and it is worth saying how. It is outsidesrc/, so thequalified-call scan never looked at it; and it has no
src/twin, sotest-copies-in-sync.cpp—which enumerates
{crisp_punc, crisp_lid, crisp_truecase}and compares each file against asrc/copy — structurally could not see it either. It surfaced on a re-run of the provenance rule over
every tracked C/C++ file rather than over
src/: all 1201 of them outsideggml/andthird_party/,intersecting "assigned from a
WeightLoadfield" with "passed toggml_backend_buffer_free". Thatsweep returns exactly two hits —
crisp_audio/src/audio_tower.cpp:688and a trailing-name collisionin
titanet(ctx->g.buffreed,ctx->bufloaded) that the by-hand list above already cleared.crisp_audiohas two in-tree consumers,qwen3_asrandhiggs_stt, and one out-of-tree one,CrispEmbed's BidirLM-Omni audio path.
Left unconverted, verified rather than assumed: the seven backends that already free through
core_gguf::free_weights;crispasr.cpp, whose Whisper loader allocates a backend-owned buffer withggml_backend_alloc_ctx_tensors_from_buftand never maps; andcrispasr_vad_encdec.cpp,marblenet_vad.cpp,miotts.cpp,omnivoice.cppandcore/{attention,dac_decoder,fastconformer}.h,whose buffers come from their own allocations.
A guard worth considering, not included here
The
crisp_audiomiss is the second time a name- or directory-scoped scan has been the thing thatfailed, and neither the compiler nor any existing test can see it:
ggml_backend_buffer_freeon aloader buffer is a legal call that compiles and runs.
The check that would catch it is mechanical, and it is the same rule the conversion above was derived
from, run as a test rather than once by hand. For every tracked production source file: collect the
expressions assigned from a
WeightLoadfield, collect the expressions passed toggml_backend_buffer_free, and fail on any name in both. Two details are what make it worth havingrather than a thing that passes while seeing nothing:
<wl>.buf/.buf_cpumust be classified into a form the scan understands — an assignment whose left side itcaptures, or a recognised read (
free_weights,release_weight_buffer, a comparison). Anythingelse fails the test rather than being skipped. That is what would have covered the
using namespace core_ggufspelling and the sibling-translation-unit destructor, both of which aplain qualified-name grep misses silently.
test-copies-in-sync.cppalready uses for its pair list. A new top-level directory carrying C++that includes
core/gguf_loader.hand is not listed fails the test. That is exactly the gapcrisp_audio/fell through.It would need normalising
ctx.model_bufagainstctx->model_buf, and thetitanettrailing-namecollision above says the comparison has to be on the whole member chain, not the last component.
Left out of this PR to keep it to the leak and its fix.
Tests
Three new/extended files, each failing before the change and passing after. None needs a model file,
a download, or a GPU to build.
tests/test-gguf-release.cppCRISPASR_GGUF_MMAP=1and=0.tests/test-gguf-mapping-released.cppbuffer_from_host_ptr; the CPU-mmap leg still runs on the Linux unit tier.tests/test-gguf-bounds.cpptests/test-gguf-split-alloc.cppCRISPASR_GGUF_MAX_ALLOC_CHUNKlowers the 1.5 GiB limit so an eight-tensor synthetic model chunks at 1 KiB — that branch previously needed a multi-gigabyte allocation, which is why it had no coverage and why the leak survived.tests/test-crisp-audio-mapping-released.cppcrisp_audio's own handle reaches the release, driven through the public C API on a synthetic one-layer tower. Pre-fix a singlecrisp_audio_init_from_file/crisp_audio_freeleaves 1 region, five cycles leave 5, and a GGUF that maps and is then rejected for missing tower tensors leaves 1; post-fix all three are 0. The CPU leg passes either way and says so in-file — it is there so the case is not a pure skip where no device advertisesbuffer_from_host_ptr.tests/test-region-probe.hholds the region probe. One note in case it is useful elsewhere:proc_regionfilenamealone is not usable for this. Asked about the base of an anonymous regionit answers with the file of the next region at or above that address, so an unrelated 67 MB anonymous
neighbour was counted as a mapping of the weight file. The probe reads
PROC_PIDREGIONPATHINFO,which returns the region and its path in one record. Linux uses
/proc/self/maps; elsewhere thecases report unsupported and skip.
Deliberately not proposed: a footprint- or RSS-growth test. It samples a whole-process figure, needs
a threshold, and would be the flaky case in this suite. The region check answers the same question
exactly. The split-chunk case says in-file what it cannot prove — that the memory came back — since
ggml exposes no per-backend allocation counter.
Verification
ctest --test-dir build -L unit -E live: 1606 passed.clang-format18: clean.cppcheck: clean (the one report atqwen3_tts.cpp:3032is pre-existingand only appears on cppcheck ≥ 2.17; CI's 2.7 does not produce it).
clang-tidy: no new findingsbeyond the file's existing
readability-braces-around-statementsstyle.A downstream Rust consumer's weights-present suite — Omni CTC 1B/3B/7B, Whisper, the wav2vec2/MMS
forced aligner, pyannote diarization, enhancement — 2059 passed / 0 failed, byte-identical
results and identical goldens before and after.
Memory, same machine and build, tests serial, macOS
phys_footprint:CRISPASR_GGUF_MMAP=0Releasing the mapping costs no wall clock — a 500-test run measured 154.6 s against 157.7 s.
The CrispEmbed half
core/gguf_loader.{h,cpp}exists in both CrispASR and CrispEmbed, and the header records a run ofcommits spent recovering from drift between them.
tests/test-copies-in-sync.cppguards fourteenduplicated files but compares paths within one checkout, so it structurally cannot see that pair —
and a new public
core_gguffunction is exactly the kind of change that drifts.This PR does not stand alone: merging it without the CrispEmbed patch breaks that build.
CrispEmbed compiles three shared libraries out of this repo —
crisp_audio,crisp_puncandcrisp_lid— against its own copy of the loader header, and all three now callrelease_weight_buffer. Checked rather than reasoned about: with the CrispEmbed patch reverted, aCrispEmbed build stops at
crisp_audio/src/audio_tower.cpp:692andcrisp_lid/src/lid_cld3.cpp:807with no member named 'release_weight_buffer' in namespace 'core_gguf'.
The companion PR is
mculbert/CrispEmbed, branchfix/gguf-release-weight-buffer. It is not a copyof this change — CrispEmbed's loader is a different lineage. There, the no-copy path is opt-in
(
load_weights(..., try_mmap=false)by default), the mapping already lives inWeightLoad::mmap_addr/mmap_len, andfree_weightsalready unmaps, so CrispEmbed has no liveinstance of this leak: its only two
try_mmapcallers keep the wholeWeightLoad. What that patchdoes is add
release_weight_bufferwith the mapping keyed to the buffer instead of only to theWeightLoad, so the name means the same thing in both repos and the shared sources are correctwhichever header they compile against. It also closes a latent hole on that side — eleven CrispEmbed
models move
wl.bufinto their own struct and free it directly, each of which would leak the momenttry_mmapwere switched on for it.Its own
tests/test_gguf_loader_mmap.cppgained the region check: 1 mapping while loaded, 0 afterfree_weights.Authored by Claude Opus 5.